Skip to content

docs(spec): ApprovalDecisionResult records what the stranded-decision throw carries - #16983

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15439-approval-decision-result-stranded-posture
Sep 8, 2026
Merged

docs(spec): ApprovalDecisionResult records what the stranded-decision throw carries#16983
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15439-approval-decision-result-stranded-posture

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #15439

Clause-②: no
No accept set moves, no member is added or removed, no export changes. The deliverable is
docblock prose recording a posture the already-merged services half implements — pulling the
contract back to what is declared, which is the ordinary lane.

The falsification test came first, and the premise held — one half of the ruling, not both

The card and its unlock scan both warned that this might be zero work: the resumed docblock
looked like it already recorded the ruling's item 2. Measured on origin/main 50dc2141a,
locating ApprovalDecisionResult by symbol, never by the card's rotted :622 anchor, it
records one half of item 2 and not the other.

The standard, verbatim from the #13807 ruling (maintainer 2026-09-04, director batch #37):

The ApprovalDecisionResult contract docblock records the declared posture: a finalised
decision whose resume fails throws with the decision and run identified, never a
half-state; the fields are the published way to read it.

Half A — "throws … never a half-state" — was already discharged. The resumed docblock on
origin/main, verbatim:

A decision that finalises a flow-bound request and CANNOT resume its OWN run throws rather
than returning resumed: false — a recorded decision whose flow never advances is the zombie
half-state of #4420.

Half B — "with the decision and run identified; the fields are the published way to read it" —
was not.
Nothing anywhere in ApprovalDecisionResult said what the throw carries. Scanning the
whole 927-line contract file rather than the interface alone, finalized appears in a docblock
exactly three times outside the member declarations, and all three are inside the
ResumeFailureReport docblock — a different symbol, where the mention is a ⛔ Not …
exclusion note drawing a boundary away from StrandedDecisionDetails, not a record of what the
decide door's own throw publishes. The ruling names the ApprovalDecisionResult docblock.

⚠️ The near-miss the unlock scan flagged is real and worth naming, because it is why a count could
not settle this: resumeError / resumeFailure are "the published way to read" a resume
failure — but of the other event. Those two carry the #16472 family's failure behind an
answer that still succeeded
. The stranded decision does not answer at all; it throws, and its four
facts ride the ERROR body. Reading the first as discharging the second is the conflation this PR's
new paragraph closes explicitly.

What this changes — prose only

One paragraph appended to the resumed docblock of ApprovalDecisionResult, plus a changeset.
No member added, no member changed, no export moved.

   * success answer per the #16472 ruling, never thrown.
+  *
+  * What that throw carries is published, not prose only (#13807, maintainer
+  * ruling 2026-09-04, decision batch #37). The status code does not move — a
+  * durable decision over a run that will not advance is still a failure —
+  * but the 500-class `RESUME_FAILED` it raises names, on its ERROR body, the
+  * four facts a caller needs: `finalized` (the decision stands), `decision`,
+  * `runId`, and `repairable` — the engine's own `'stranded'` discriminator
+  * carried through, never inferred from the message text. That envelope is
+  * `StrandedDecisionDetails` (`@objectstack/types`), attached by
+  * `strandedDecisionFailure` and read back by `strandedDecisionDetails`; the
+  * REST approvals door merges it into the `RESUME_FAILED` response body.
+  * Those four facts are the published way to read the posture this member
+  * declares — a caller holding only the status code reads a bare 500 as "the
+  * decision did not happen", and the row IS terminal.
+  *
+  * ⛔ They are not members of this result and must never be added to it:
+  * they ride the ERROR, so declaring them here would declare a success shape
+  * that never carries them. ⛔ Nor are they {@link resumeFailure}, which
+  * reports the other event of the #16472 ruling — a resume failure behind an
+  * answer that still succeeded.
   */
  resumed?: boolean;

⛔ The card's loudest fence is honoured and then written into the file: the four facts are not
added to ApprovalDecisionResult. The new text says so in as many words, so the next reader who
notices the asymmetry finds the reason rather than repeating the mistake.

Every citation in the new prose is a symbolStrandedDecisionDetails,
strandedDecisionFailure, strandedDecisionDetails, RESUME_FAILED — and no path:NNN line
reference is introduced. git diff -U0 | grep -P '^\+.*\.ts:\d+' matches nothing.

The sentence is true — read in the merged code, not inferred from the PR title

Each claim measured on origin/main 50dc2141a:

  • The four facts are attached to the throw. ApprovalService's private resumeRecordedOutcome
    (packages/plugins/plugin-approvals/src/approval-service.ts) ends its catch arm with
    throw strandedDecisionFailure(…, { finalized: true, decision, runId, repairable }).
  • repairable is the engine's discriminator, not the message text. The same arm computes
    const repairable = status === 'stranded'; from ApprovalService.resumeStatusOf(err), with the
    comment "the engine's own discriminator decides repairable, never this door and never the
    message text".
  • They reach the ERROR body. handleApprovalError in packages/rest/src/rest-server.ts calls
    strandedDecisionDetails(err) and spreads the result into the response object under
    code === 'RESUME_FAILED' — top-level members of the error body, presence-gated, never
    synthesised.
  • The code really is 500-class and does not move. That handler's mapping table binds
    /^RESUME_FAILED/ to 500. RESUME_TARGET_LOST (409) is a different exit: it is raised by
    assertRunResumable before any write, so it is not a stranded decision and the new prose
    does not claim it.
  • StrandedDecisionDetails is unchanged by this PR — it lives in
    packages/types/src/stranded-decision.ts and this PR does not touch that package.

Sequencing verified rather than assumed, as Blocked-by: #13807 demanded: #13807 is closed and
PR #15436 is merged, so the sentence describes fields that exist on main today. Writing it
earlier would have put a false statement into the contract.

Changeset — decided on BOTH halves, with controls that discriminate

Half 1 alone gives the wrong answer here. packages/spec's files[] is
['dist','json-schema','liveness','prompts','llms.txt','README.md','src/**/*.zod.ts','CHANGELOG.md','api-surface','spec-changes.json'],
and src/contracts/approval-service.ts is not a *.zod.ts ⇒ half 1 says "not published".

Half 2 decides it. After pnpm --filter @objectstack/spec build, grepping the built tree:

reading grep result
the changed text What that throw carries is published dist/contracts/index.d.ts, dist/contracts/index.d.mts
positive control (pre-existing docblock prose from the same interface) zombie half-state of #4420 same two files ⇒ contract docblocks do reach dist
negative control (text living only in a sibling *.test.ts) the retired predicate is gone from the contract file present in src, absent from dist ⇒ the grep is not matching everything

dist is in files[], so the changed text ships. ⇒ a patch changeset for
@objectstack/spec is owed and is included. (The text reaches no other files[] entry —
json-schema, liveness, prompts, api-surface, llms.txt and spec-changes.json all
answer zero.)

Checks — by name, exit codes captured before any pipe

All at the final commit 025709234.

check verdict
pnpm lint (repo-wide, eslint . --no-inline-config) 0 — the whole repo, so no narrowing is claimed and none is owed
pnpm --filter @objectstack/spec test 0 — 467 test files, 13100 tests passed
pnpm --filter @objectstack/spec typecheck 0
pnpm --filter @objectstack/spec check:test-typecheck 0 — the test layer compiles; debt ledger held
pnpm --filter @objectstack/spec build 0 — 34/34 declaration files emitted
pnpm --filter @objectstack/spec check:generated 0 — all 15 generated artifacts up to date, nothing to regenerate
resume-failure-report.pin.test.ts (the pin that reads this contract's source text) 0 — 6/6
derived gate families (scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack) 69 of 70 exit 0

The pin test matters here because it reads approval-service.ts as text. Its three textual
invariants were re-measured after the edit and still hold: the retired predicate is still absent
from the file, resumeError?: string; still appears 4 times and
resumeFailure?: ResumeFailureReport; twice, and both resumeFailure docblocks still carry the
absence sentence.

NOT MEASURED, declared to CI: pnpm check:dual-build-cjs-loads exits 3
PREREQUISITE NOT MET, its own distinct code for "nothing was measured", because it reads built
output for ~55 packages that a targeted build does not produce. Clearing it is a whole-repo
pnpm build, which is CI's run, not a per-card one; a docblock inside a .ts file emits no CJS or
ESM entry point, so this diff cannot move what that gate reads. Two other gates first exited 3 for
the same reason and were then measured green after building their named prerequisites
(@objectstack/formula, @objectstack/lint, @objectstack/objectql):
check:doc-formula-expressions and check:lean-entry-closure — so 3 is read as NOT MEASURED
throughout, never as a pass and never as a finding.

Out of scope, noted and deliberately not filed

The new paragraph is prose that nothing pins. resume-failure-report.pin.test.ts states in
its own header that anything about StrandedDecisionDetails is "⛔ Not pinned, deliberately" —
that exclusion belongs to the #16472 family it gates, and it means this sentence can rot the way
the docblock line references this lane repointed today did. A pin (the natural shape: assert the
resumed docblock names all four facts, with the producer's four keys read from
@objectstack/types rather than re-spelled) is a new test file, and this card's scope is the
docblock plus one changeset. Left to the PM to judge rather than filed by this seat, and the
carrying PR is this one.


Generated by Claude Code

… throw carries

The contract already declared the posture: a decision that finalises a
flow-bound request and cannot resume its own run throws rather than
answering `resumed: false`, because a recorded decision whose flow never
advances is #4420's zombie half-state. What it never said is that the
throw is now readable.

The maintainer ruling #13807 (2026-09-04, decision batch #37) owes both
halves — "throws with the decision and run identified; the fields are the
published way to read it" — and only the first half was written down. The
`resumed` docblock now names the second: the 500-class `RESUME_FAILED`
carries `finalized`, `decision`, `runId` and `repairable` on its ERROR
body as `StrandedDecisionDetails` (`@objectstack/types`, attached by
`strandedDecisionFailure`, read back by `strandedDecisionDetails`, merged
into the response by the REST approvals door), and the status code does
not move because a durable decision over a run that will not advance is
still a failure.

Prose only. No member is added: the four facts ride the ERROR, so putting
them on this success shape would declare a shape that never carries them,
and the docblock says so explicitly. It also keeps them distinct from
`resumeFailure`, which reports the #16472 family's different event — a
resume failure told behind an answer that still succeeded.

The changeset is owed on the second half of the two-part test, not the
first: `src/contracts/approval-service.ts` is outside `packages/spec`'s
`files[]`, but the changed docblock text is emitted into
`dist/contracts/index.d.ts` and `index.d.mts`, which `files[]` publishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
@github-actions github-actions Bot added the size/s label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json eda26cef07771f812c8091f97d935bc14900eb77packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5941baef8cefa735de22934c9568cb676d2dbff0 — the merge of head 0257092344564d16e642e94cc19c9381c8edddd2 into base eda26cef07771f812c8091f97d935bc14900eb77, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5941baef8cefa735de22934c9568cb676d2dbff0 && git checkout 5941baef8cefa735de22934c9568cb676d2dbff0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eda26cef07771f812c8091f97d935bc14900eb77 0257092344564d16e642e94cc19c9381c8edddd2 && git checkout -B drift-repro eda26cef07771f812c8091f97d935bc14900eb77 && git merge --no-ff 0257092344564d16e642e94cc19c9381c8edddd2

node scripts/docs-audit/affected-docs.mjs --json eda26cef07771f812c8091f97d935bc14900eb77

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Sep 8, 2026
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 8, 2026 22:58
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 2bdabe6 Sep 8, 2026
36 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15439-approval-decision-result-stranded-posture branch September 8, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tooling

Projects

None yet

2 participants